Add appguardrail serve: multi-tenant control-plane API (platform seed)#198
Conversation
…m seed)
The $2B unlock a CLI can't be: a persistent, multi-tenant surface. Instead of a
one-shot scan, an org pushes every CI scan and queries its history/trend — the
recurring-revenue backbone. Stdlib only (sqlite3 + http.server), ships in the
same wheel, no dependency.
- appguardrail_core/controlplane.py: SQLite store (orgs + scans), API-key auth
(sha256-hashed), tenant-scoped add/list/get, and an HTTP API. Counts and
deploy-blocking are computed from findings on ingest.
- `appguardrail serve [--db --host --port] [--create-org NAME]`; bootstraps a
default org + API key on an empty DB.
- Endpoints: POST/GET /api/v1/scans, GET /api/v1/scans/{id}, GET /api/v1/health;
Authorization: Bearer <key>.
- Tests: tests/test_controlplane.py (store, counts, tenant isolation, health,
ingest+history round-trip, 401 auth, 400 bad body) + module self-check.
- Verified: full suite 215 passed; e2e provision -> POST scan -> GET history,
401 on missing/wrong key, cross-tenant reads blocked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3
Gives the control-plane API a user-facing surface: an org's security posture over time, not just JSON endpoints. - scanner/dashboard/console.html: single static page (no framework/build) that connects with an API key and renders scan history, the deploy-blocking trend (per-scan bars), and per-scan finding detail. Reuses the design tokens; all interpolation HTML-escaped. - controlplane.py: the server serves the console at `/`, `/console`, `/index.html` (packaged via importlib.resources, dashboard/*.html). - Tests: console served at root (200 + marker). - Verified: full suite 216 passed; puppeteer e2e — connect with key, history + trend + detail render, a CRITICAL finding shows in scan detail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3
Turns the platform into continuous monitoring: CI pushes each scan and the control plane reports what got *worse* since last time. - controlplane.py: add_scan computes `new_blocking` — deploy-blocking findings new since this org+repo's previous scan (line-independent fingerprint), stored and returned. list/get expose it. - `appguardrail scan --push <url>`: POST normalized findings to /api/v1/scans (key from APPGUARDRAIL_API_KEY; repo/commit from GITHUB_REPOSITORY/GITHUB_SHA). Missing key / push failure degrades gracefully and never fails the scan. - console.html: surfaces "New since last scan" as a stat and a per-scan drift column. - Tests: drift (first scan all-new, moved-line not-new, newly-introduced counted, per-repo baseline). - Verified: full suite 217 passed; e2e scan --push -> control plane ingests with drift; missing key skips gracefully. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3
Completes the CI integration: the installed `appguardrail monitor` workflow now pushes each scan to a control plane when configured, so history + drift populate automatically — with zero change for users who don't set it up. - MONITOR_WORKFLOW: job-level CP_URL from the APPGUARDRAIL_CONTROL_PLANE_URL secret; the scan step adds `--push $CP_URL` only when it's set (SARIF upload + deploy gate unchanged). APPGUARDRAIL_API_KEY passed from secrets. - Updated the monitor-workflow test to assert the push wiring. - Verified: full suite 217 passed; `appguardrail monitor` installs the workflow with the conditional push step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3
Closes detect -> alert. Drift was visible but passive; now the control plane pushes an alert the moment CI introduces a new deploy-blocking finding, which is the action teams actually pay for. - controlplane.py: orgs gain a webhook_url; set_webhook() and a POST /api/v1/webhook endpoint set it. add_scan fires _send_alert() (best-effort, never fails ingest) with an event payload only when new_blocking > 0. - Tests: alert fires on new blockers and not on a moved/unchanged finding, no webhook -> no delivery, API sets the webhook. - Verified: full suite 220 passed; e2e against a local receiver — alerts land only for scans that introduce new blockers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3
…plane (#201) Multi-tenant needs multi-user: an org shares one key today, so CI, a read-only dashboard, and an admin all hold the same all-powerful credential. This adds per-key roles. - controlplane.py: `keys` table (org_id, key_hash, role, label). create_key() issues a role-scoped key; role_for_key() resolves (org_id, role) and treats the bootstrap key as owner; has_role() enforces viewer<member<owner. create_org now seeds an owner key. - API enforcement: GET (read) = any role; POST /scans = member+; POST /webhook and POST /keys = owner. New POST /api/v1/keys mints role-scoped keys. - Tests: role resolution + has_role ordering, and the full API role matrix (viewer read-only, member ingest-only, owner full) via the live server. - Verified: full suite 236 passed; e2e role matrix (viewer/member/owner) enforced. Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Scan history grows unbounded; the API returned everything and the console had no first-class trend series. Adds paging and a chart-ready trend. - controlplane.py: list_scans() gains an offset; new scan_trend() returns an oldest->newest deploy_blocking/new_blocking series. GET /api/v1/scans accepts ?limit=&offset=; new GET /api/v1/scans/trend?limit=. - Tests: pagination offset windows + trend ordering/shape, store and API level. - Verified: full suite passed; e2e limit/offset paging + trend series. Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
When an org's drift-alert webhook URL is a Slack Incoming Webhook (host hooks.slack.com), render the alert as a Block Kit message: a header with the new deploy-blocking count, a fields section for org / new blockers / repo / scan id, and a section listing the top 5 offending rule_id/file pairs with a "+N more" overflow line. Text is Slack-escaped and length-trimmed to Block Kit caps. Any non-Slack URL still receives the existing generic JSON payload unchanged (backward compatible). The format is chosen inside _send_alert via host detection; add_scan now passes the org name and the list of newly-introduced blocking findings through so the Slack renderer can name them. No new dependency (stdlib urllib only). Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review of the control plane found two real gaps, both reachable by any holder of a valid key: - Request bodies were unbounded: a huge Content-Length OOMs the server, and a negative one makes rfile.read(-1) block until EOF. Now capped at 10MiB and negatives rejected (400) before reading. - limit/offset query ints were passed through unclamped: sqlite treats LIMIT -1 as *no limit*, so ?limit=-1 dumped the org's entire scan history, bypassing the pagination cap. Now clamped (list 1..1000, trend 1..365, offset >= 0). Reviewed-and-cleared in the same pass: role validation on key creation (safe fallback to member), unknown-role handling in has_role (rank -1), key lookup by sha256 hash (no timing-sensitive comparison), tenant isolation on all reads (existing tests). Tests: 3 new (limit=-1 / huge limit+negative offset clamped; 50MiB body -> 400; negative Content-Length -> 400). Full suite 245 passed. Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
SHA-256 is a fast, non-memory-hard hash unsuitable for hashing secrets: if the control-plane store leaks, an attacker can brute-force API keys at billions of guesses per second. Switch _hash_key to hashlib.scrypt, a memory-hard KDF from the stdlib, with a fixed application salt so the hash stays deterministic and keys remain findable by indexed equality lookup. Resolves CodeQL py/weak-sensitive-data-hashing (high).
# Conflicts: # CHANGELOG.md # README.md # scanner/cli/appguardrail.py
OpenCode Review Overview
Pull request overviewOpenCode reviewed the current-head bounded evidence and found no blocking issues. FindingsNo blocking findings. SummaryApproval sufficiency: bounded evidence supplied affirmative approval evidence for changed files, coverage/docstring posture, risk surfaces, and current-head verification; approval is not based merely on the absence of known blockers.
Changed-File Evidence Mapflowchart LR
PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
Evidence --> S1["Changed file (5 files)"]
S1 --> I1["repository behavior"]
I1 --> R1["Review risk: Changed file (5 files)"]
R1 --> V1["required checks"]
Evidence --> S2["Test (2 files)"]
S2 --> I2["regression suite"]
I2 --> R2["Review risk: Test (2 files)"]
R2 --> V2["targeted test run"]
|
There was a problem hiding this comment.
Pull request overview
OpenCode reviewed the current-head bounded evidence and found no blocking issues.
Findings
No blocking findings.
Summary
Approval sufficiency: bounded evidence supplied affirmative approval evidence for changed files, coverage/docstring posture, risk surfaces, and current-head verification; approval is not based merely on the absence of known blockers.
Verification posture: CodeGraph evidence was initialized and bounded current-head evidence reviewed for changed-file evidence including CHANGELOG.md, README.md, appguardrail_core/controlplane.py, scanner/cli/appguardrail.py, scanner/dashboard/console.html, and 2 more.
Linter/static: workflow/static review evidence is bounded by the current-head GitHub Checks gate and changed-file evidence.
TDD/regression: coverage execution evidence and focused changed hunks were reviewed from bounded-review-evidence.md.
Coverage: coverage execution evidence reports supported repository test suites passed.
Docstring coverage: coverage execution evidence reports configured repository docstring gates passed or docstring coverage was advisory.
DAG: CodeGraph/source-backed behavior map connects CHANGELOG.md to the affected review, runtime, or workflow path and required checks.
PoC/execution: coverage-evidence job executed on the current head and reported PASS.
DDD/domain: workflow and repository-governance invariants were reviewed against changed files in bounded evidence.
CDD/context: CodeGraph evidence, changed-file history, and focused hunks were reviewed from bounded-review-evidence.md.
Similar issues: changed-file history evidence was reviewed for comparable local precedents.
Claim/concept check: bounded evidence, repository source, current-head workflow evidence, and, where numeric, scientific, statistical, or literature-backed claims are affected, original-paper/formula evidence and parameter-recovery expectations were used for claims.
Standards search: standards and external-source checks are delegated to configured OpenCode web_search/Context7/DeepWiki sources when applicable; no evidence-backed standards blocker is present in bounded evidence.
Compatibility/convention: changed workflow/script conventions, object naming, and reserved-word safety for schema/API/config/code surfaces were checked in bounded evidence.
Breaking-change/backcompat: deployment evidence and changed-file history were checked for backward-compatibility risk.
Performance: changed surfaces were checked for performance risk in bounded evidence.
Developer experience: changed automation, review, test, setup, and maintenance surfaces were checked for helpful or obstructive DX impact in bounded evidence.
User experience: connected user, operator, API, CLI, documentation, review-comment, status-check, rendering, and workflow-reader behavior was checked for contradictions against code, docs, and tests in bounded evidence.
Visual/DOM: Playwright visual, DOM locator, ARIA snapshot, console, and responsive evidence were checked when a web UI surface was present; for non-web surfaces, API/CLI/log/docs/workflow interaction evidence was reviewed instead.
Accessibility/i18n: accessibility, localization, and human-readable text surfaces were checked where UI, CLI, API message, docs, logs, or review text changed.
Supply-chain/license: dependency, package, model, container, and external-tool changes were checked in bounded evidence.
Packaging: package, build, test, lint, and security contracts were checked in bounded evidence.
Security/privacy: workflow-token, review-gate, and repository-automation security/privacy boundaries were checked in bounded evidence.
- Result: APPROVE
- Reason: PR introduces a well-tested, secure, and maintainable multi-tenant control-plane API with no unresolved issues.
- Head SHA:
cb0b859d14ffc078f593c035ee1cb2866c50dbaa - Workflow run: 29086877461
- Workflow attempt: 1
Changed-File Evidence Map
flowchart LR
PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]
Evidence --> S1["Changed file (5 files)"]
S1 --> I1["repository behavior"]
I1 --> R1["Review risk: Changed file (5 files)"]
R1 --> V1["required checks"]
Evidence --> S2["Test (2 files)"]
S2 --> I2["regression suite"]
I2 --> R2["Review risk: Test (2 files)"]
R2 --> V2["targeted test run"]
Summary
The lever a CLI alone can't be: a persistent, multi-tenant surface. Instead of a one-shot scan that prints and exits, an org pushes every CI scan to a store and queries its history/trend over time — the continuous-monitoring, recurring-revenue backbone. Stdlib only (
sqlite3+http.server), so it ships in the same wheel with no new dependency.appguardrail_core/controlplane.py— SQLite store (orgs+scans), API-key auth (sha256-hashed keys), tenant-scoped add/list/get (cross-org reads return nothing), and an HTTP API. Severity counts and deploy-blocking totals are computed from the findings on ingest.appguardrail serve [--db --host --port] [--create-org NAME]— provisions an org + API key; bootstraps adefaultorg + key on an empty DB so it runs out of the box.POST /api/v1/scans(ingest{findings:[...], repo?, commit?}),GET /api/v1/scans(history),GET /api/v1/scans/{id}(full),GET /api/v1/health. Auth:Authorization: Bearer <api_key>.appguardrail scan --findings-jsonthen POSTs the result.SQLite + stdlib is the deliberate
ponytailstarting point — swap the store for a managed database behind the same functions when scale demands it.Test plan
pytest— 215 passed, incl.tests/test_controlplane.py(store add/list/get + counts, tenant isolation, health without auth, ingest→history round-trip, 401 on missing/wrong key, 400 on a bad body) + module self-checkserve --create-orgprints a key →POST /api/v1/scans(authed) →GET /api/v1/scansreturns history; missing/wrong key → 401Follow-ups: an org web dashboard consuming this API, scheduled re-scan / drift alerts, org-scoped RBAC.
🤖 Generated with Claude Code